In [1]:
# Cell 1: Imports and configuration
import os
import math
import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.cluster import DBSCAN
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error

# User settings
TOTAL_INPUT_PATH = r"C:\Users\adith\Downloads\2mM TBPB_.jpg"  # <- change if needed
INNER_SCALE = 0.72
EXPECTED_COLS = 12
CONCENTRATIONS = [0.5,1,2,3,4,5,6,7,8,9,10]
SATURATION_THRESHOLD = 35
MIN_BLOB_AREA = 60
PLOT_DPI = 100

# Create output folder for debugging images if you want to save (optional)
OUT_DEBUG = None  # set to a path string to save images, e.g. r"D:\FYPROJJ\debug"
if OUT_DEBUG:
    os.makedirs(OUT_DEBUG, exist_ok=True)

# Display defaults
plt.rcParams["figure.dpi"] = PLOT_DPI
In [3]:
# Cell 2: Utility functions

def imshow_rgb(img_bgr, title=None, figsize=(8,6)):
    """Show BGR image in notebook as RGB with matplotlib"""
    plt.figure(figsize=figsize)
    plt.imshow(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
    if title:
        plt.title(title)
    plt.axis('off')
    plt.show()

def imshow_gray(img_gray, title=None, cmap='gray', figsize=(8,6)):
    plt.figure(figsize=figsize)
    plt.imshow(img_gray, cmap=cmap)
    if title:
        plt.title(title)
    plt.axis('off')
    plt.show()

def to_hsv(img_bgr):
    return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)

def hsv_to_display_rgb(hsv):
    # convert HSV back to RGB for pleasant display
    return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)

def mask_from_hsv(hsv, s_thresh=30, v_thresh=40):
    s = hsv[:,:,1]
    v = hsv[:,:,2]
    mask = ((s > s_thresh) & (v > v_thresh)).astype(np.uint8) * 255
    return mask

def morphological_clean(mask, open_k=3, close_k=5):
    ko = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_k,open_k))
    kc = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_k,close_k))
    m_open = cv2.morphologyEx(mask, cv2.MORPH_OPEN, ko, iterations=1)
    m_close = cv2.morphologyEx(m_open, cv2.MORPH_CLOSE, kc, iterations=1)
    return m_open, m_close

def contours_from_mask(mask):
    cnts, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    return cnts

def contours_overlay(img_bgr, contours, color=(0,255,0), thickness=2, show=True, title=None):
    out = img_bgr.copy()
    cv2.drawContours(out, contours, -1, color, thickness)
    if show:
        imshow_rgb(out, title=title)
    return out

def contours_to_circles(contours, area_min=MIN_BLOB_AREA, circ_min=0.3):
    """Return list of (x,y,r,area,circularity) for contours passing thresholds."""
    blobs = []
    for c in contours:
        area = cv2.contourArea(c)
        if area < 5:
            continue
        peri = cv2.arcLength(c, True)
        if peri == 0:
            continue
        circ = 4*math.pi*area/(peri*peri)
        (x,y), r = cv2.minEnclosingCircle(c)
        if area >= area_min and circ >= circ_min:
            blobs.append((int(x), int(y), int(round(r)), float(area), float(circ)))
    # left-to-right
    blobs = sorted(blobs, key=lambda b: b[0])
    return blobs

def overlay_circles(img_bgr, blobs, inner_scale=INNER_SCALE, annotate=True, title=None):
    out = img_bgr.copy()
    for i,(x,y,r,area,circ) in enumerate(blobs):
        cv2.circle(out, (x,y), int(r), (0,255,0), 2)              # outer
        cv2.circle(out, (x,y), max(1,int(r*inner_scale)), (255,0,0), 2)  # inner ROI
        if annotate:
            cv2.putText(out, str(i+1), (x-10,y+int(r)+12), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 2)
    imshow_rgb(out, title=title)
    return out

def hough_circles_fallback(img_bgr, dp=1.2, minDist=24, param1=80, param2=28, minR=12, maxR=60):
    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5,5), 1.2)
    circles = cv2.HoughCircles(blur, cv2.HOUGH_GRADIENT, dp=dp, minDist=minDist,
                               param1=param1, param2=param2, minRadius=minR, maxRadius=maxR)
    if circles is None:
        return []
    circles = np.round(circles[0]).astype(int)
    blobs = [(int(x),int(y),int(r),0.0,0.0) for (x,y,r) in circles]
    blobs = sorted(blobs, key=lambda b: b[0])
    return blobs

def extract_inner_rgb_and_saturation(img_bgr, img_hsv, x, y, r, inner_scale=INNER_SCALE):
    inner_r = max(1, int(r * inner_scale))
    h, w = img_bgr.shape[:2]
    mask = np.zeros((h,w), dtype=np.uint8)
    cv2.circle(mask, (x,y), inner_r, 255, -1)
    px = img_bgr[mask==255]
    hsv_px = img_hsv[mask==255]
    if px.size == 0 or hsv_px.size == 0:
        return (np.nan, np.nan, np.nan, np.nan, np.nan)
    B = float(np.mean(px[:,0])); G = float(np.mean(px[:,1])); R = float(np.mean(px[:,2]))
    S_mean = float(np.mean(hsv_px[:,1])); V_mean = float(np.mean(hsv_px[:,2]))
    RGB_mean = (R+G+B)/3.0
    return R, G, B, RGB_mean, S_mean

def cluster_rows_by_y_from_blobs(blobs, eps_mul=1.8, min_samples=2):
    """Cluster by Y coordinate and return ordered list of lists of blob tuples."""
    if len(blobs) == 0:
        return []
    ys = np.array([b[1] for b in blobs]).reshape(-1,1).astype(float)
    radii = np.array([b[2] for b in blobs])
    med_r = max(6.0, np.median(radii))
    eps = med_r * eps_mul
    db = DBSCAN(eps=eps, min_samples=min_samples).fit(ys)
    labels = db.labels_
    label_map = {}
    for lbl,b in zip(labels, blobs):
        if lbl == -1:
            continue
        label_map.setdefault(int(lbl), []).append(b)
    rows = []
    for lbl,blist in label_map.items():
        avg_y = np.mean([b[1] for b in blist])
        rows.append((lbl, avg_y, blist))
    rows_sorted = sorted(rows, key=lambda t: t[1])
    ordered = [r[2] for r in rows_sorted]
    return ordered
In [5]:
# Cell 3: Full-image stepwise pipeline outputs

img = cv2.imread(TOTAL_INPUT_PATH)
if img is None:
    raise FileNotFoundError(f"Cannot load image: {TOTAL_INPUT_PATH}")

print("STEP: Original image")
imshow_rgb(img, title="Original Image (Full Page)", figsize=(12,8))

# 1) Convert to HSV
hsv = to_hsv(img)
print("STEP: HSV conversion (showing as HSV->RGB for display)")
imshow_rgb(hsv_to_display_rgb(hsv), title="HSV (display)")

# 2) Show S and V channels separately
print("STEP: Saturation and Value channels")
imshow_gray(hsv[:,:,1], title="Saturation (S) channel")
imshow_gray(hsv[:,:,2], title="Value (V) channel")

# 3) Threshold in HSV (S & V)
s_thresh = 30; v_thresh = 30
mask = mask_from_hsv(hsv, s_thresh, v_thresh)
print(f"STEP: HSV threshold mask (S>{s_thresh}, V>{v_thresh}) -> pixels= {np.sum(mask>0)}")
imshow_gray(mask, title=f"HSV Mask (S>{s_thresh}, V>{v_thresh})")

# 4) Morphological open (show)
m_open, m_close = morphological_clean(mask, open_k=3, close_k=5)
print("STEP: Morphological OPEN result")
imshow_gray(m_open, title="After Morphological Open")

# 5) Morphological close (show)
print("STEP: Morphological CLOSE result")
imshow_gray(m_close, title="After Morphological Close")

# 6) Contour extraction on closed mask
contours = contours_from_mask(m_close)
print(f"STEP: Contours extracted: {len(contours)}")
contours_overlay(img, contours, title="All Contours (from HSV mask)")

# 7) Filter contours -> circular blobs
blobs = contours_to_circles(contours, area_min=MIN_BLOB_AREA, circ_min=0.32)
print(f"STEP: Filtered circular blobs (area>={MIN_BLOB_AREA}, circ>=0.32): {len(blobs)}")
overlay_circles(img, blobs, title="Circular blobs (HSV + contour + circularity)")

# 8) Hough fallback (show)
hough_blobs = hough_circles_fallback(img, dp=1.2, minDist=28, param1=80, param2=26, minR=12, maxR=60)
print(f"STEP: Hough circles fallback candidates: {len(hough_blobs)}")
# overlay hough on original
overlay_circles(img, hough_blobs, title="Hough Circles (Fallback)")

# 9) Final combined view (prefer contour-based blobs, add Hough if needed)
final_blobs = blobs.copy()
# If contour-based found < expected and Hough has candidates, try to merge some non-duplicate
if len(final_blobs) < EXPECTED_COLS and len(hough_blobs) > 0:
    for hb in hough_blobs:
        hx,hy,hr,_,_ = hb
        too_close = False
        for b in final_blobs:
            if np.hypot(b[0]-hx, b[1]-hy) < 0.6*max(b[2], hr):
                too_close = True; break
        if not too_close:
            final_blobs.append(hb)
final_blobs = sorted(final_blobs, key=lambda b: b[0])
print(f"STEP: Final blobs used (combined): {len(final_blobs)}")
overlay_circles(img, final_blobs, title="Final Detected Wells (Full Image)")
STEP: Original image
No description has been provided for this image
STEP: HSV conversion (showing as HSV->RGB for display)
No description has been provided for this image
STEP: Saturation and Value channels
No description has been provided for this image
No description has been provided for this image
STEP: HSV threshold mask (S>30, V>30) -> pixels= 190745
No description has been provided for this image
STEP: Morphological OPEN result
No description has been provided for this image
STEP: Morphological CLOSE result
No description has been provided for this image
STEP: Contours extracted: 56
No description has been provided for this image
STEP: Filtered circular blobs (area>=60, circ>=0.32): 41
No description has been provided for this image
STEP: Hough circles fallback candidates: 263
No description has been provided for this image
STEP: Final blobs used (combined): 41
No description has been provided for this image
Out[5]:
array([[[249, 248, 250],
        [249, 248, 250],
        [249, 248, 250],
        ...,
        [251, 249, 249],
        [251, 249, 249],
        [250, 248, 248]],

       [[249, 248, 250],
        [249, 248, 250],
        [249, 248, 250],
        ...,
        [254, 252, 252],
        [254, 252, 252],
        [254, 252, 252]],

       [[249, 248, 250],
        [249, 248, 250],
        [249, 248, 250],
        ...,
        [255, 254, 254],
        [255, 255, 255],
        [255, 255, 255]],

       ...,

       [[199, 194, 196],
        [199, 194, 196],
        [199, 194, 196],
        ...,
        [248, 250, 251],
        [248, 250, 251],
        [248, 250, 251]],

       [[199, 194, 196],
        [199, 194, 196],
        [199, 194, 196],
        ...,
        [248, 250, 251],
        [248, 250, 251],
        [248, 250, 251]],

       [[199, 194, 196],
        [199, 194, 196],
        [199, 194, 196],
        ...,
        [248, 250, 251],
        [248, 250, 251],
        [248, 250, 251]]], dtype=uint8)
In [7]:
# Cell 4: Cluster detected blobs into rows and create row crops

rows = cluster_rows_by_y_from_blobs(final_blobs)
print(f"Rows found by clustering: {len(rows)}")

# Show center lines for each row on the full image
overlay = img.copy()
for ridx, row_blobs in enumerate(rows):
    avg_y = int(np.mean([b[1] for b in row_blobs]))
    cv2.line(overlay, (0, avg_y), (overlay.shape[1], avg_y), (255,0,255), 2)
    # mark row centers
    for b in row_blobs:
        cv2.circle(overlay, (b[0], b[1]), 3, (0,255,255), -1)
imshow_rgb(overlay, title="Clustered Rows (center lines & blob centers)", figsize=(12,6))

# Create and show crops for each row (with margin)
row_crops = []
for i,row_blobs in enumerate(rows, start=1):
    xs = [b[0] for b in row_blobs]; ys = [b[1] for b in row_blobs]; rs = [b[2] for b in row_blobs]
    if len(xs) == 0:
        continue
    x_min = max(0, int(min(xs) - max(rs) - 20))
    x_max = min(img.shape[1], int(max(xs) + max(rs) + 20))
    y_min = max(0, int(min(ys) - max(rs) - 20))
    y_max = min(img.shape[0], int(max(ys) + max(rs) + 20))
    crop = img[y_min:y_max, x_min:x_max].copy()
    row_crops.append((i, crop, (x_min,y_min,x_max,y_max)))
    imshow_rgb(crop, title=f"Row crop {i} (blobs: {len(row_blobs)})", figsize=(10,3))

print(f"Created {len(row_crops)} row crops.")
Rows found by clustering: 3
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
Created 3 row crops.
In [9]:
# Cell 5: For each row crop, run stepwise detection and extract RGB + S_mean
all_trial_dfs = []

for (rid, crop, bbox) in row_crops:
    print(f"\n=== Processing row crop {rid} ===")
    imshow_rgb(crop, title=f"Row {rid} - Original crop", figsize=(10,3))

    hsv_c = to_hsv(crop)
    imshow_rgb(hsv_to_display_rgb(hsv_c), title=f"Row {rid} - HSV (display)", figsize=(10,3))
    imshow_gray(hsv_c[:,:,1], title=f"Row {rid} - S channel")
    imshow_gray(hsv_c[:,:,2], title=f"Row {rid} - V channel")

    # threshold
    mask_c = mask_from_hsv(hsv_c, s_thresh=30, v_thresh=30)
    imshow_gray(mask_c, title=f"Row {rid} - HSV mask (S>30,V>30)")

    # morphological open & close
    m_open_c, m_close_c = morphological_clean(mask_c, open_k=3, close_k=5)
    imshow_gray(m_open_c, title=f"Row {rid} - Morph open")
    imshow_gray(m_close_c, title=f"Row {rid} - Morph close")

    # contours
    cnts_c = contours_from_mask(m_close_c)
    print(f"Row {rid}: contours found = {len(cnts_c)}")
    contours_overlay(crop, cnts_c, title=f"Row {rid} - All contours")

    # circularity filtering
    blobs_c = contours_to_circles(cnts_c, area_min=MIN_BLOB_AREA, circ_min=0.3)
    print(f"Row {rid}: blobs after area/circularity filter = {len(blobs_c)}")
    overlay_circles(crop, blobs_c, title=f"Row {rid} - Circular blobs (filtered)")

    # fallback: add hough if needed
    if len(blobs_c) < EXPECTED_COLS:
        hough_c = hough_circles_fallback(crop, dp=1.2, minDist=24, param1=80, param2=26, minR=12, maxR=60)
        print(f"Row {rid}: Hough fallback candidates = {len(hough_c)}")
        overlay_circles(crop, hough_c, title=f"Row {rid} - Hough fallback")
        # merge non-duplicates
        for hb in hough_c:
            hx,hy,hr,_,_ = hb
            too_close = False
            for b in blobs_c:
                if np.hypot(b[0]-hx, b[1]-hy) < 0.6*max(b[2], hr):
                    too_close = True; break
            if not too_close:
                blobs_c.append(hb)
        blobs_c = sorted(blobs_c, key=lambda b: b[0])
        overlay_circles(crop, blobs_c, title=f"Row {rid} - Combined blobs")

    # final selection: at most EXPECTED_COLS, take largest if more
    if len(blobs_c) > EXPECTED_COLS:
        blobs_c = sorted(blobs_c, key=lambda b: b[3], reverse=True)[:EXPECTED_COLS]
        blobs_c = sorted(blobs_c, key=lambda b: b[0])

    overlay_circles(crop, blobs_c, title=f"Row {rid} - Final blobs (annotated)")

    # If number of blobs is not EXPECTED_COLS, warn (we will still extract what we have)
    if len(blobs_c) != EXPECTED_COLS:
        print(f"Warning: row {rid} has {len(blobs_c)} detected wells (expected {EXPECTED_COLS}).")

    # Extract inner RGB + S for each detected blob (left->right)
    records = []
    for idx, b in enumerate(blobs_c[:EXPECTED_COLS]):   # use only first EXPECTED_COLS if more
        x,y,r,area,circ = b
        Rval,Gval,Bval,RGBmean,Smean = extract_inner_rgb_and_saturation(crop, hsv_c, x, y, r)
        # map concentrations skipping control at index 0
        if idx == 0:
            conc = None
        else:
            conc = CONCENTRATIONS[idx-1] if idx-1 < len(CONCENTRATIONS) else None
        records.append({
            "Trial": rid,
            "Well": idx+1,
            "Concentration": conc,
            "R": Rval, "G": Gval, "B": Bval, "RGB_mean": RGBmean, "S_mean": Smean
        })
    df_row = pd.DataFrame(records, columns=["Trial","Well","Concentration","R","G","B","RGB_mean","S_mean"])
    print(f"Row {rid} extracted dataframe:")
    display(df_row)
    all_trial_dfs.append((rid, df_row))
=== Processing row crop 1 ===
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
Row 1: contours found = 17
No description has been provided for this image
Row 1: blobs after area/circularity filter = 13
No description has been provided for this image
No description has been provided for this image
Row 1 extracted dataframe:
Trial Well Concentration R G B RGB_mean S_mean
0 1 1 NaN 241.173975 229.983027 183.999057 218.385353 60.507779
1 1 2 0.5 178.925067 215.213769 200.073792 198.070876 43.051350
2 1 3 1.0 168.120198 210.516546 200.736402 193.124382 51.408520
3 1 4 2.0 153.456930 205.127614 202.250266 186.944937 64.357320
4 1 5 3.0 152.904998 203.729529 199.699752 185.444760 63.658986
5 1 6 4.0 145.106631 201.277574 202.266578 182.883594 72.790736
6 1 7 5.0 142.227224 200.953563 203.613258 182.264682 77.064516
7 1 8 6.0 137.264445 199.589507 205.485289 180.779747 84.672102
8 1 9 7.0 133.848384 199.744085 208.281573 180.624681 91.112296
9 1 10 8.0 129.863446 196.822366 204.875998 177.187270 93.375428
10 1 11 9.0 133.673442 200.028657 210.800733 181.500944 93.210263
11 1 12 10.0 122.045728 196.789791 210.133641 176.323053 106.878766
=== Processing row crop 2 ===
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
Row 2: contours found = 30
No description has been provided for this image
Row 2: blobs after area/circularity filter = 19
No description has been provided for this image
No description has been provided for this image
Row 2 extracted dataframe:
Trial Well Concentration R G B RGB_mean S_mean
0 2 1 NaN 239.892946 227.476072 178.818859 215.395959 64.935838
1 2 2 0.5 180.392769 215.974123 201.439915 199.268935 42.068061
2 2 3 1.0 170.055915 211.914797 202.179536 194.716749 50.430202
3 2 4 2.0 156.432471 206.071606 202.734846 188.412974 61.520028
4 2 5 3.0 152.571476 204.901033 204.483172 187.318560 65.799067
5 2 6 4.0 146.088501 202.211592 205.005921 184.435338 73.434403
6 2 7 5.0 142.261720 200.692214 203.541378 182.165104 76.878516
7 2 8 6.0 140.119437 200.592621 206.357931 182.356663 81.903766
8 2 9 7.0 132.351549 200.019993 212.301233 181.557592 96.036654
9 2 10 8.0 133.995392 199.863169 207.197802 180.352121 90.180078
10 2 11 9.0 132.396348 199.330544 208.765310 180.164067 93.282617
11 2 12 10.0 123.890464 196.753633 210.687345 177.110481 105.067706
=== Processing row crop 3 ===
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
Row 3: contours found = 28
No description has been provided for this image
Row 3: blobs after area/circularity filter = 21
No description has been provided for this image
No description has been provided for this image
Row 3 extracted dataframe:
Trial Well Concentration R G B RGB_mean S_mean
0 3 1 NaN 239.807870 227.497341 180.710032 216.005081 62.852180
1 3 2 0.5 180.209966 216.487258 204.551541 200.416255 42.793838
2 3 3 1.0 168.604750 211.494505 203.212691 194.437315 51.770649
3 3 4 2.0 159.248052 207.575569 204.976317 190.599979 59.493300
4 3 5 3.0 155.056315 205.210930 203.129957 187.799067 62.549817
5 3 6 4.0 150.979794 203.783410 205.220844 186.661349 67.695498
6 3 7 5.0 143.573910 202.886211 209.749380 185.403167 80.496987
7 3 8 6.0 139.904365 200.138954 206.614129 182.219149 82.473176
8 3 9 7.0 137.621411 201.537398 209.950727 183.036512 87.902871
9 3 10 8.0 133.723502 199.121943 209.900035 180.915160 92.558667
10 3 11 9.0 131.946118 200.461538 211.651188 181.352948 96.031549
11 3 12 10.0 130.914215 198.462602 209.472173 179.616330 95.658986
In [11]:
# Cell 6: Collect valid trials (exactly EXPECTED_COLS and S_mean >= threshold for all wells except control)
valid_trials = []
for (rid, df_row) in all_trial_dfs:
    if len(df_row) < EXPECTED_COLS:
        print(f"Row {rid} skipped: only {len(df_row)} wells detected.")
        continue
    # check S_mean for wells (skip control idx 0)
    svals = df_row["S_mean"].values
    # consider all wells present and non-NaN
    if np.any(np.isnan(svals)):
        print(f"Row {rid} skipped due to NaN S values.")
        continue
    # verify S >= threshold for all non-control wells
    if np.all(svals >= SATURATION_THRESHOLD):
        valid_trials.append((rid, df_row))
    else:
        print(f"Row {rid} skipped due to low saturation in some wells (S mean min={np.min(svals):.2f}).")

print(f"\nValid trials count: {len(valid_trials)}")
combined_rows = []
for (tid, df_row) in valid_trials:
    # drop control (Well==1) when building concentration mapping
    df_use = df_row[df_row["Well"] != 1].copy().reset_index(drop=True)
    # ensure concentrations column is filled (should be from previous step)
    combined_rows.append((tid, df_use))

if len(combined_rows) == 0:
    print("No valid trials to analyze further.")
else:
    for tid, df_use in combined_rows:
        print(f"\n--- Trial {tid} (used for calibration) ---")
        display(df_use)
Valid trials count: 3

--- Trial 1 (used for calibration) ---
Trial Well Concentration R G B RGB_mean S_mean
0 1 2 0.5 178.925067 215.213769 200.073792 198.070876 43.051350
1 1 3 1.0 168.120198 210.516546 200.736402 193.124382 51.408520
2 1 4 2.0 153.456930 205.127614 202.250266 186.944937 64.357320
3 1 5 3.0 152.904998 203.729529 199.699752 185.444760 63.658986
4 1 6 4.0 145.106631 201.277574 202.266578 182.883594 72.790736
5 1 7 5.0 142.227224 200.953563 203.613258 182.264682 77.064516
6 1 8 6.0 137.264445 199.589507 205.485289 180.779747 84.672102
7 1 9 7.0 133.848384 199.744085 208.281573 180.624681 91.112296
8 1 10 8.0 129.863446 196.822366 204.875998 177.187270 93.375428
9 1 11 9.0 133.673442 200.028657 210.800733 181.500944 93.210263
10 1 12 10.0 122.045728 196.789791 210.133641 176.323053 106.878766
--- Trial 2 (used for calibration) ---
Trial Well Concentration R G B RGB_mean S_mean
0 2 2 0.5 180.392769 215.974123 201.439915 199.268935 42.068061
1 2 3 1.0 170.055915 211.914797 202.179536 194.716749 50.430202
2 2 4 2.0 156.432471 206.071606 202.734846 188.412974 61.520028
3 2 5 3.0 152.571476 204.901033 204.483172 187.318560 65.799067
4 2 6 4.0 146.088501 202.211592 205.005921 184.435338 73.434403
5 2 7 5.0 142.261720 200.692214 203.541378 182.165104 76.878516
6 2 8 6.0 140.119437 200.592621 206.357931 182.356663 81.903766
7 2 9 7.0 132.351549 200.019993 212.301233 181.557592 96.036654
8 2 10 8.0 133.995392 199.863169 207.197802 180.352121 90.180078
9 2 11 9.0 132.396348 199.330544 208.765310 180.164067 93.282617
10 2 12 10.0 123.890464 196.753633 210.687345 177.110481 105.067706
--- Trial 3 (used for calibration) ---
Trial Well Concentration R G B RGB_mean S_mean
0 3 2 0.5 180.209966 216.487258 204.551541 200.416255 42.793838
1 3 3 1.0 168.604750 211.494505 203.212691 194.437315 51.770649
2 3 4 2.0 159.248052 207.575569 204.976317 190.599979 59.493300
3 3 5 3.0 155.056315 205.210930 203.129957 187.799067 62.549817
4 3 6 4.0 150.979794 203.783410 205.220844 186.661349 67.695498
5 3 7 5.0 143.573910 202.886211 209.749380 185.403167 80.496987
6 3 8 6.0 139.904365 200.138954 206.614129 182.219149 82.473176
7 3 9 7.0 137.621411 201.537398 209.950727 183.036512 87.902871
8 3 10 8.0 133.723502 199.121943 209.900035 180.915160 92.558667
9 3 11 9.0 131.946118 200.461538 211.651188 181.352948 96.031549
10 3 12 10.0 130.914215 198.462602 209.472173 179.616330 95.658986
In [13]:
# Cell 7: Per-trial R-only analysis and plotting

def fit_inverse_model_and_report(df_trial, trial_id, deg_forward=2, deg_inverse=2):
    # df_trial should have Concentration and R columns, no control
    C = df_trial["Concentration"].values
    R = df_trial["R"].values.reshape(-1,1)

    # forward model R = f(C) for plotting
    poly_f = PolynomialFeatures(deg_forward, include_bias=False)
    Xf = poly_f.fit_transform(C.reshape(-1,1))
    model_f = LinearRegression().fit(Xf, R.ravel())

    # inverse model C = g(R) for prediction
    poly_i = PolynomialFeatures(deg_inverse, include_bias=False)
    Xi = poly_i.fit_transform(R)
    model_i = LinearRegression().fit(Xi, C)

    # predictions & metrics
    C_pred_from_R = model_i.predict(poly_i.transform(R))
    r2 = r2_score(C, C_pred_from_R)
    mae = mean_absolute_error(C, C_pred_from_R)
    rmse = math.sqrt(mean_squared_error(C, C_pred_from_R))

    # plot R vs C and forward fit
    xs = np.linspace(min(C), max(C), 200).reshape(-1,1)
    ys = model_f.predict(poly_f.transform(xs))

    plt.figure(figsize=(6,4))
    plt.scatter(C, R, label="observed (R)", color='blue')
    plt.plot(xs, ys, 'r-', label="forward fit R=f(C)")
    plt.xlabel("Concentration")
    plt.ylabel("R channel")
    plt.title(f"Trial {trial_id}: R vs Concentration (deg_fwd={deg_forward})")
    plt.legend(); plt.grid(True)
    plt.show()

    # Print metrics and per-well predictions
    print(f"Trial {trial_id} metrics: R2={r2:.4f}, MAE={mae:.4f}, RMSE={rmse:.4f}")
    df_pred = pd.DataFrame({
        "Well": df_trial["Well"].values,
        "True_Conc": C,
        "R_value": R.ravel(),
        "Pred_Conc_from_R": C_pred_from_R
    })
    display(df_pred)
    return {"r2": r2, "mae": mae, "rmse": rmse, "model_inv": model_i, "poly_inv": poly_i, "model_fwd": model_f, "poly_fwd": poly_f}

# Run for each valid trial
results = {}
for tid, df_use in combined_rows:
    results[tid] = fit_inverse_model_and_report(df_use, tid, deg_forward=2, deg_inverse=2)
No description has been provided for this image
Trial 1 metrics: R2=0.9541, MAE=0.4072, RMSE=0.6629
Well True_Conc R_value Pred_Conc_from_R
0 2 0.5 178.925067 0.336259
1 3 1.0 168.120198 0.944719
2 4 2.0 153.456930 2.784838
3 5 3.0 152.904998 2.876912
4 6 4.0 145.106631 4.354717
5 7 5.0 142.227224 4.983883
6 8 6.0 137.264445 6.173993
7 9 7.0 133.848384 7.070935
8 10 8.0 129.863446 8.197355
9 11 9.0 133.673442 7.118575
10 12 10.0 122.045728 10.657813
No description has been provided for this image
Trial 2 metrics: R2=0.9693, MAE=0.4232, RMSE=0.5416
Well True_Conc R_value Pred_Conc_from_R
0 2 0.5 180.392769 0.434614
1 3 1.0 170.055915 0.862787
2 4 2.0 156.432471 2.401778
3 5 3.0 152.571476 3.039515
4 6 4.0 146.088501 4.310573
5 7 5.0 142.261720 5.178660
6 8 6.0 140.119437 5.702810
7 9 7.0 132.351549 7.833232
8 10 8.0 133.995392 7.352332
9 11 9.0 132.396348 7.819912
10 12 10.0 123.890464 10.563786
No description has been provided for this image
Trial 3 metrics: R2=0.9898, MAE=0.2424, RMSE=0.3131
Well True_Conc R_value Pred_Conc_from_R
0 2 0.5 180.209966 0.580173
1 3 1.0 168.604750 0.986845
2 4 2.0 159.248052 2.059969
3 5 3.0 155.056315 2.756510
4 6 4.0 150.979794 3.561975
5 7 5.0 143.573910 5.348393
6 8 6.0 139.904365 6.387970
7 9 7.0 137.621411 7.086363
8 10 8.0 133.723502 8.370341
9 11 9.0 131.946118 8.994142
10 12 10.0 130.914215 9.367319
In [ ]: